@abraca/convert 2.4.0 → 2.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +28 -0
- package/dist/abracadabra-convert.cjs +91 -62
- package/dist/abracadabra-convert.cjs.map +1 -1
- package/dist/abracadabra-convert.esm.js +91 -62
- package/dist/abracadabra-convert.esm.js.map +1 -1
- package/package.json +1 -1
- package/src/file-blocks/paths.ts +26 -3
- package/src/markdown-to-yjs.ts +44 -10
- package/src/yjs-to-markdown.ts +89 -44
package/README.md
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# @abraca/convert
|
|
2
|
+
|
|
3
|
+
The canonical Markdown ↔ Yjs/TipTap round-trip converter — shared by cou-sh, abracadabra-nuxt, and `@abraca/mcp` instead of each vendoring its own copy. **Zero runtime dependencies** (`yjs` peer-only).
|
|
4
|
+
|
|
5
|
+
## Documentation
|
|
6
|
+
|
|
7
|
+
Full, code-derived documentation lives in [`docs/`](docs/) — the node/mark spec, the
|
|
8
|
+
universal-meta spec, the conversion API, diff + file-blocks, and a precise
|
|
9
|
+
fidelity/gotchas reference. It is the source of truth (the old `SPEC.md` was untrusted
|
|
10
|
+
and removed).
|
|
11
|
+
|
|
12
|
+
## API
|
|
13
|
+
|
|
14
|
+
```ts
|
|
15
|
+
import { populateYDocFromMarkdown, yjsToMarkdown } from "@abraca/convert";
|
|
16
|
+
populateYDocFromMarkdown(doc.getXmlFragment("default"), markdown);
|
|
17
|
+
const { title, markdown } = /* via */ yjsToMarkdown(fragment, label);
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Fragment shape: `[documentHeader, documentMeta, ...body]`. Also `populateYDocFromHtml` (needs a global `DOMParser`), `yjsToHtml`, `yjsToPlainText`, structural `diff.ts`, and the FS-sync `file-blocks/` sidecar.
|
|
21
|
+
|
|
22
|
+
::
|
|
23
|
+
|
|
24
|
+
> **Attach-before-fill is load-bearing** — `Y.XmlText.insert` on a detached node reverses text (YATA clock 0). Coverage is partial: several spec nodes/marks have no runtime branch, and frontmatter handles a ~13-key subset. See [`docs/4.reference/`](docs/4.reference/1.fidelity-and-gotchas.md).
|
|
25
|
+
|
|
26
|
+
## License
|
|
27
|
+
|
|
28
|
+
MIT.
|
|
@@ -131,6 +131,23 @@ function parseFrontmatter(markdown) {
|
|
|
131
131
|
body
|
|
132
132
|
};
|
|
133
133
|
}
|
|
134
|
+
function pushNested(out, inner, wrap) {
|
|
135
|
+
const children = parseInline(inner);
|
|
136
|
+
if (children.length === 0) {
|
|
137
|
+
out.push({
|
|
138
|
+
text: inner,
|
|
139
|
+
attrs: { ...wrap }
|
|
140
|
+
});
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
for (const child of children) out.push({
|
|
144
|
+
text: child.text,
|
|
145
|
+
attrs: {
|
|
146
|
+
...child.attrs ?? {},
|
|
147
|
+
...wrap
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
}
|
|
134
151
|
function parseInline(text) {
|
|
135
152
|
const stripped = text.replace(/\{lang="[^"]*"\}/g, "").replace(/:(?!badge|icon|kbd)(\w[\w-]*)\[([^\]]*)\](\{[^}]*\})?/g, "$2").replace(/:(?!badge|icon|kbd)(\w[\w-]*)(\{[^}]*\})/g, "");
|
|
136
153
|
const tokens = [];
|
|
@@ -179,22 +196,10 @@ function parseInline(text) {
|
|
|
179
196
|
text: label,
|
|
180
197
|
attrs: { docLink: { docId } }
|
|
181
198
|
});
|
|
182
|
-
} else if (match[10] !== void 0) tokens
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
});
|
|
186
|
-
else if (match[11] !== void 0) tokens.push({
|
|
187
|
-
text: match[11],
|
|
188
|
-
attrs: { bold: true }
|
|
189
|
-
});
|
|
190
|
-
else if (match[12] !== void 0) tokens.push({
|
|
191
|
-
text: match[12],
|
|
192
|
-
attrs: { italic: true }
|
|
193
|
-
});
|
|
194
|
-
else if (match[13] !== void 0) tokens.push({
|
|
195
|
-
text: match[13],
|
|
196
|
-
attrs: { italic: true }
|
|
197
|
-
});
|
|
199
|
+
} else if (match[10] !== void 0) pushNested(tokens, match[10], { strike: true });
|
|
200
|
+
else if (match[11] !== void 0) pushNested(tokens, match[11], { bold: true });
|
|
201
|
+
else if (match[12] !== void 0) pushNested(tokens, match[12], { italic: true });
|
|
202
|
+
else if (match[13] !== void 0) pushNested(tokens, match[13], { italic: true });
|
|
198
203
|
else if (match[14] !== void 0) tokens.push({
|
|
199
204
|
text: match[14],
|
|
200
205
|
attrs: { code: true }
|
|
@@ -730,11 +735,19 @@ function parseBlocks(markdown) {
|
|
|
730
735
|
function fillTextInto(el, tokens) {
|
|
731
736
|
const filtered = tokens.filter((t) => t.text.length > 0);
|
|
732
737
|
if (!filtered.length) return;
|
|
733
|
-
const
|
|
734
|
-
|
|
738
|
+
const children = filtered.map((tok) => {
|
|
739
|
+
return (tok.attrs?.docLink)?.docId ? new yjs.XmlElement("docLink") : new yjs.XmlText();
|
|
740
|
+
});
|
|
741
|
+
el.insert(0, children);
|
|
735
742
|
filtered.forEach((tok, i) => {
|
|
736
|
-
|
|
737
|
-
|
|
743
|
+
const node = children[i];
|
|
744
|
+
if (node instanceof yjs.XmlElement) {
|
|
745
|
+
const dl = tok.attrs.docLink;
|
|
746
|
+
node.setAttribute("docId", dl.docId);
|
|
747
|
+
return;
|
|
748
|
+
}
|
|
749
|
+
if (tok.attrs) node.insert(0, tok.text, tok.attrs);
|
|
750
|
+
else node.insert(0, tok.text);
|
|
738
751
|
});
|
|
739
752
|
}
|
|
740
753
|
function blockElName(b) {
|
|
@@ -1090,6 +1103,15 @@ function populateYDocFromMarkdown(fragment, markdown, fallbackTitle = "Untitled"
|
|
|
1090
1103
|
|
|
1091
1104
|
//#endregion
|
|
1092
1105
|
//#region packages/convert/src/yjs-to-markdown.ts
|
|
1106
|
+
function isXElem(n) {
|
|
1107
|
+
return !!n && typeof n.nodeName === "string";
|
|
1108
|
+
}
|
|
1109
|
+
function isXText(n) {
|
|
1110
|
+
return !!n && typeof n.nodeName !== "string" && typeof n.toDelta === "function";
|
|
1111
|
+
}
|
|
1112
|
+
function localizeFragment(fragment) {
|
|
1113
|
+
return fragment;
|
|
1114
|
+
}
|
|
1093
1115
|
function serializeDelta(delta) {
|
|
1094
1116
|
let result = "";
|
|
1095
1117
|
for (const op of delta) {
|
|
@@ -1150,12 +1172,15 @@ function serializeDelta(delta) {
|
|
|
1150
1172
|
}
|
|
1151
1173
|
function serializeInline(el) {
|
|
1152
1174
|
const parts = [];
|
|
1153
|
-
for (const child of el.toArray()) if (child
|
|
1154
|
-
else if (child
|
|
1175
|
+
for (const child of el.toArray()) if (isXText(child)) parts.push(serializeDelta(child.toDelta()));
|
|
1176
|
+
else if (isXElem(child)) if (child.nodeName === "docLink") {
|
|
1177
|
+
const docId = child.getAttribute("docId") ?? "";
|
|
1178
|
+
parts.push(`[[${docId}]]`);
|
|
1179
|
+
} else parts.push(serializeInline(child));
|
|
1155
1180
|
return parts.join("");
|
|
1156
1181
|
}
|
|
1157
1182
|
function serializeBlock(el, indent = "") {
|
|
1158
|
-
if (el
|
|
1183
|
+
if (isXText(el)) return serializeDelta(el.toDelta());
|
|
1159
1184
|
switch (el.nodeName) {
|
|
1160
1185
|
case "documentHeader":
|
|
1161
1186
|
case "documentMeta": return "";
|
|
@@ -1175,7 +1200,7 @@ function serializeBlock(el, indent = "") {
|
|
|
1175
1200
|
}
|
|
1176
1201
|
case "blockquote": {
|
|
1177
1202
|
const lines = [];
|
|
1178
|
-
for (const child of el.toArray()) if (child
|
|
1203
|
+
for (const child of el.toArray()) if (isXElem(child)) {
|
|
1179
1204
|
const text = serializeBlock(child);
|
|
1180
1205
|
for (const line of text.split("\n")) lines.push(`> ${line}`);
|
|
1181
1206
|
}
|
|
@@ -1236,11 +1261,11 @@ function serializeBlock(el, indent = "") {
|
|
|
1236
1261
|
if (to) props.push(`to="${to}"`);
|
|
1237
1262
|
return `::card${props.length ? `{${props.join(" ")}}` : ""}\n${serializeChildren(el)}\n::`;
|
|
1238
1263
|
}
|
|
1239
|
-
case "cardGroup": return `::card-group\n${el.toArray().filter((c) => c
|
|
1240
|
-
case "codeCollapse": return `::code-collapse\n${el.toArray().filter((c) => c
|
|
1241
|
-
case "codeGroup": return `::code-group\n${el.toArray().filter((c) => c
|
|
1264
|
+
case "cardGroup": return `::card-group\n${el.toArray().filter((c) => isXElem(c)).map((c) => serializeBlock(c)).join("\n\n")}\n::`;
|
|
1265
|
+
case "codeCollapse": return `::code-collapse\n${el.toArray().filter((c) => isXElem(c) && c.nodeName === "codeBlock").map((c) => serializeBlock(c)).join("\n\n")}\n::`;
|
|
1266
|
+
case "codeGroup": return `::code-group\n${el.toArray().filter((c) => isXElem(c) && c.nodeName === "codeBlock").map((c) => serializeBlock(c)).join("\n\n")}\n::`;
|
|
1242
1267
|
case "codePreview": {
|
|
1243
|
-
const children = el.toArray().filter((c) => c
|
|
1268
|
+
const children = el.toArray().filter((c) => isXElem(c));
|
|
1244
1269
|
const nonCode = children.filter((c) => c.nodeName !== "codeBlock").map((c) => serializeBlock(c)).join("\n\n");
|
|
1245
1270
|
const code = children.filter((c) => c.nodeName === "codeBlock").map((c) => serializeBlock(c)).join("\n\n");
|
|
1246
1271
|
const parts = [nonCode];
|
|
@@ -1258,16 +1283,16 @@ function serializeBlock(el, indent = "") {
|
|
|
1258
1283
|
if (required === true || required === "true") props.push("required=\"true\"");
|
|
1259
1284
|
return `::field{${props.join(" ")}}\n${serializeChildren(el)}\n::`;
|
|
1260
1285
|
}
|
|
1261
|
-
case "fieldGroup": return `::field-group\n${el.toArray().filter((c) => c
|
|
1286
|
+
case "fieldGroup": return `::field-group\n${el.toArray().filter((c) => isXElem(c)).map((c) => serializeBlock(c)).join("\n\n")}\n::`;
|
|
1262
1287
|
default: return serializeChildren(el);
|
|
1263
1288
|
}
|
|
1264
1289
|
}
|
|
1265
1290
|
function serializeChildren(el) {
|
|
1266
1291
|
const blocks = [];
|
|
1267
|
-
for (const child of el.toArray()) if (child
|
|
1292
|
+
for (const child of el.toArray()) if (isXElem(child)) {
|
|
1268
1293
|
const text = serializeBlock(child);
|
|
1269
1294
|
if (text) blocks.push(text);
|
|
1270
|
-
} else if (child
|
|
1295
|
+
} else if (isXText(child)) {
|
|
1271
1296
|
const text = serializeDelta(child.toDelta());
|
|
1272
1297
|
if (text) blocks.push(text);
|
|
1273
1298
|
}
|
|
@@ -1277,11 +1302,11 @@ function serializeListItems(el, type, indent) {
|
|
|
1277
1302
|
const lines = [];
|
|
1278
1303
|
let counter = 1;
|
|
1279
1304
|
for (const child of el.toArray()) {
|
|
1280
|
-
if (!(child
|
|
1305
|
+
if (!isXElem(child) || child.nodeName !== "listItem") continue;
|
|
1281
1306
|
const prefix = type === "bullet" ? "- " : `${counter++}. `;
|
|
1282
1307
|
const subParts = [];
|
|
1283
1308
|
for (const sub of child.toArray()) {
|
|
1284
|
-
if (!(sub
|
|
1309
|
+
if (!isXElem(sub)) continue;
|
|
1285
1310
|
if (sub.nodeName === "bulletList") subParts.push(serializeListItems(sub, "bullet", indent + " "));
|
|
1286
1311
|
else if (sub.nodeName === "orderedList") subParts.push(serializeListItems(sub, "ordered", indent + " "));
|
|
1287
1312
|
else subParts.push(serializeInline(sub));
|
|
@@ -1297,13 +1322,13 @@ function serializeListItems(el, type, indent) {
|
|
|
1297
1322
|
function serializeTaskList(el, indent) {
|
|
1298
1323
|
const lines = [];
|
|
1299
1324
|
for (const child of el.toArray()) {
|
|
1300
|
-
if (!(child
|
|
1325
|
+
if (!isXElem(child) || child.nodeName !== "taskItem") continue;
|
|
1301
1326
|
const checked = child.getAttribute("checked");
|
|
1302
1327
|
const marker = checked === true || checked === "true" ? "[x]" : "[ ]";
|
|
1303
1328
|
let header = "";
|
|
1304
1329
|
const nestedParts = [];
|
|
1305
1330
|
for (const sub of child.toArray()) {
|
|
1306
|
-
if (!(sub
|
|
1331
|
+
if (!isXElem(sub)) continue;
|
|
1307
1332
|
if (sub.nodeName === "paragraph" && header === "") header = serializeInline(sub);
|
|
1308
1333
|
else if (sub.nodeName === "bulletList") nestedParts.push(serializeListItems(sub, "bullet", indent + " "));
|
|
1309
1334
|
else if (sub.nodeName === "orderedList") nestedParts.push(serializeListItems(sub, "ordered", indent + " "));
|
|
@@ -1316,16 +1341,16 @@ function serializeTaskList(el, indent) {
|
|
|
1316
1341
|
return lines.join("\n");
|
|
1317
1342
|
}
|
|
1318
1343
|
function getCodeBlockText(el) {
|
|
1319
|
-
for (const child of el.toArray()) if (child
|
|
1344
|
+
for (const child of el.toArray()) if (isXText(child)) return child.toString();
|
|
1320
1345
|
return "";
|
|
1321
1346
|
}
|
|
1322
1347
|
function serializeTable(el) {
|
|
1323
|
-
const rows = el.toArray().filter((c) => c
|
|
1348
|
+
const rows = el.toArray().filter((c) => isXElem(c));
|
|
1324
1349
|
if (!rows.length) return "";
|
|
1325
1350
|
const serializedRows = [];
|
|
1326
1351
|
for (const row of rows) {
|
|
1327
|
-
const cells = row.toArray().filter((c) => c
|
|
1328
|
-
return cell.toArray().filter((c) => c
|
|
1352
|
+
const cells = row.toArray().filter((c) => isXElem(c)).map((cell) => {
|
|
1353
|
+
return cell.toArray().filter((c) => isXElem(c)).map((c) => serializeInline(c)).join(" ");
|
|
1329
1354
|
});
|
|
1330
1355
|
serializedRows.push(cells);
|
|
1331
1356
|
}
|
|
@@ -1344,7 +1369,7 @@ function serializeTable(el) {
|
|
|
1344
1369
|
].join("\n");
|
|
1345
1370
|
}
|
|
1346
1371
|
function serializeSlottedContainer(el, containerName, childName, slotPrefix) {
|
|
1347
|
-
return `::${containerName}\n${el.toArray().filter((c) => c
|
|
1372
|
+
return `::${containerName}\n${el.toArray().filter((c) => isXElem(c) && c.nodeName === childName).map((item) => {
|
|
1348
1373
|
const label = item.getAttribute("label") ?? "";
|
|
1349
1374
|
const icon = item.getAttribute("icon") ?? "";
|
|
1350
1375
|
const props = [];
|
|
@@ -1394,7 +1419,7 @@ function escapeYaml(s) {
|
|
|
1394
1419
|
return s.replace(/\\/g, "\\\\").replace(/"/g, "\\\"");
|
|
1395
1420
|
}
|
|
1396
1421
|
function serializeBlockToHtml(el) {
|
|
1397
|
-
if (el
|
|
1422
|
+
if (isXText(el)) return serializeDeltaToHtml(el.toDelta());
|
|
1398
1423
|
const name = el.nodeName;
|
|
1399
1424
|
switch (name) {
|
|
1400
1425
|
case "documentHeader":
|
|
@@ -1408,11 +1433,11 @@ function serializeBlockToHtml(el) {
|
|
|
1408
1433
|
case "orderedList": return `<ol>${serializeListHtml(el)}</ol>`;
|
|
1409
1434
|
case "taskList": return `<ul>${serializeTaskListHtml(el)}</ul>`;
|
|
1410
1435
|
case "codeBlock": {
|
|
1411
|
-
const lang = el.getAttribute("language") ?? "";
|
|
1436
|
+
const lang = (el.getAttribute("language") ?? "").replace(/[^\w.-]/g, "");
|
|
1412
1437
|
const code = escapeHtml(getCodeBlockText(el));
|
|
1413
1438
|
return lang ? `<pre><code class="language-${lang}">${code}</code></pre>` : `<pre><code>${code}</code></pre>`;
|
|
1414
1439
|
}
|
|
1415
|
-
case "blockquote": return `<blockquote>\n${el.toArray().filter((c) => c
|
|
1440
|
+
case "blockquote": return `<blockquote>\n${el.toArray().filter((c) => isXElem(c)).map((c) => serializeBlockToHtml(c)).join("\n")}\n</blockquote>`;
|
|
1416
1441
|
case "table": return serializeTableHtml(el);
|
|
1417
1442
|
case "horizontalRule": return "<hr>";
|
|
1418
1443
|
case "image": {
|
|
@@ -1426,13 +1451,13 @@ function serializeBlockToHtml(el) {
|
|
|
1426
1451
|
if (uploadId) return `<!--fileblock:${uploadId}:${filename}-->`;
|
|
1427
1452
|
return `<!-- file: ${filename} -->`;
|
|
1428
1453
|
}
|
|
1429
|
-
default: return `<div data-type="${name}">\n${el.toArray().filter((c) => c
|
|
1454
|
+
default: return `<div data-type="${name}">\n${el.toArray().filter((c) => isXElem(c) || isXText(c)).map((c) => isXElem(c) ? serializeBlockToHtml(c) : serializeDeltaToHtml(c.toDelta())).join("\n")}\n</div>`;
|
|
1430
1455
|
}
|
|
1431
1456
|
}
|
|
1432
1457
|
function serializeInlineHtml(el) {
|
|
1433
1458
|
const parts = [];
|
|
1434
|
-
for (const child of el.toArray()) if (child
|
|
1435
|
-
else if (child
|
|
1459
|
+
for (const child of el.toArray()) if (isXText(child)) parts.push(serializeDeltaToHtml(child.toDelta()));
|
|
1460
|
+
else if (isXElem(child)) parts.push(serializeInlineHtml(child));
|
|
1436
1461
|
return parts.join("");
|
|
1437
1462
|
}
|
|
1438
1463
|
function serializeDeltaToHtml(delta) {
|
|
@@ -1451,23 +1476,23 @@ function serializeDeltaToHtml(delta) {
|
|
|
1451
1476
|
return result;
|
|
1452
1477
|
}
|
|
1453
1478
|
function serializeListHtml(el) {
|
|
1454
|
-
return el.toArray().filter((c) => c
|
|
1479
|
+
return el.toArray().filter((c) => isXElem(c) && c.nodeName === "listItem").map((li) => `<li>${li.toArray().filter((c) => isXElem(c)).map((c) => serializeBlockToHtml(c)).join("")}</li>`).join("\n");
|
|
1455
1480
|
}
|
|
1456
1481
|
function serializeTaskListHtml(el) {
|
|
1457
|
-
return el.toArray().filter((c) => c
|
|
1482
|
+
return el.toArray().filter((c) => isXElem(c) && c.nodeName === "taskItem").map((ti) => {
|
|
1458
1483
|
const rawChecked = ti.getAttribute("checked");
|
|
1459
1484
|
const checked = rawChecked === true || rawChecked === "true";
|
|
1460
|
-
const text = ti.toArray().filter((c) => c
|
|
1485
|
+
const text = ti.toArray().filter((c) => isXElem(c)).map((c) => serializeInlineHtml(c)).join("");
|
|
1461
1486
|
return `<li><input type="checkbox"${checked ? " checked" : ""} disabled> ${text}</li>`;
|
|
1462
1487
|
}).join("\n");
|
|
1463
1488
|
}
|
|
1464
1489
|
function serializeTableHtml(el) {
|
|
1465
|
-
const rows = el.toArray().filter((c) => c
|
|
1490
|
+
const rows = el.toArray().filter((c) => isXElem(c));
|
|
1466
1491
|
if (!rows.length) return "";
|
|
1467
1492
|
return `<table>\n${rows.map((row, ri) => {
|
|
1468
1493
|
const tag = ri === 0 ? "th" : "td";
|
|
1469
|
-
return `<tr>${row.toArray().filter((c) => c
|
|
1470
|
-
return `<${tag}>${cell.toArray().filter((c) => c
|
|
1494
|
+
return `<tr>${row.toArray().filter((c) => isXElem(c)).map((cell) => {
|
|
1495
|
+
return `<${tag}>${cell.toArray().filter((c) => isXElem(c)).map((c) => serializeInlineHtml(c)).join("")}</${tag}>`;
|
|
1471
1496
|
}).join("")}</tr>`;
|
|
1472
1497
|
}).join("\n")}\n</table>`;
|
|
1473
1498
|
}
|
|
@@ -1475,6 +1500,7 @@ function escapeHtml(s) {
|
|
|
1475
1500
|
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
1476
1501
|
}
|
|
1477
1502
|
function yjsToMarkdown(fragment, label, meta, type) {
|
|
1503
|
+
fragment = localizeFragment(fragment);
|
|
1478
1504
|
const { text: headerText, source: titleSource } = readDocumentHeader(fragment);
|
|
1479
1505
|
const effectiveTitle = headerText || label;
|
|
1480
1506
|
const docMeta = readDocumentMeta(fragment);
|
|
@@ -1498,7 +1524,7 @@ function readDocumentMeta(fragment) {
|
|
|
1498
1524
|
const meta = {};
|
|
1499
1525
|
let type;
|
|
1500
1526
|
for (const child of fragment.toArray()) {
|
|
1501
|
-
if (!(child
|
|
1527
|
+
if (!isXElem(child) || child.nodeName !== "documentMeta") continue;
|
|
1502
1528
|
const attrs = child.getAttributes();
|
|
1503
1529
|
for (const k of Object.keys(attrs)) {
|
|
1504
1530
|
const v = attrs[k];
|
|
@@ -1518,8 +1544,8 @@ function readDocumentMeta(fragment) {
|
|
|
1518
1544
|
}
|
|
1519
1545
|
function readDocumentHeader(fragment) {
|
|
1520
1546
|
for (const child of fragment.toArray()) {
|
|
1521
|
-
if (!(child
|
|
1522
|
-
const text = child.toArray().find((c) => c
|
|
1547
|
+
if (!isXElem(child) || child.nodeName !== "documentHeader") continue;
|
|
1548
|
+
const text = child.toArray().find((c) => isXText(c));
|
|
1523
1549
|
const src = child.getAttribute("titleSource");
|
|
1524
1550
|
const source = src === "h1" || src === "frontmatter" ? src : void 0;
|
|
1525
1551
|
return {
|
|
@@ -1532,7 +1558,7 @@ function readDocumentHeader(fragment) {
|
|
|
1532
1558
|
function collectBodyBlocks(fragment) {
|
|
1533
1559
|
const out = [];
|
|
1534
1560
|
for (const child of fragment.toArray()) {
|
|
1535
|
-
if (!(child
|
|
1561
|
+
if (!isXElem(child)) continue;
|
|
1536
1562
|
if (child.nodeName === "documentHeader" || child.nodeName === "documentMeta") continue;
|
|
1537
1563
|
out.push(child);
|
|
1538
1564
|
}
|
|
@@ -1567,9 +1593,10 @@ function isMetaEmpty(meta) {
|
|
|
1567
1593
|
* accessibility tooling, search indexing, and snippet previews.
|
|
1568
1594
|
*/
|
|
1569
1595
|
function yjsToPlainText(fragment) {
|
|
1596
|
+
fragment = localizeFragment(fragment);
|
|
1570
1597
|
const out = [];
|
|
1571
1598
|
const visit = (node) => {
|
|
1572
|
-
if (node
|
|
1599
|
+
if (isXText(node)) {
|
|
1573
1600
|
out.push(node.toString());
|
|
1574
1601
|
return;
|
|
1575
1602
|
}
|
|
@@ -1579,17 +1606,18 @@ function yjsToPlainText(fragment) {
|
|
|
1579
1606
|
if (alt) out.push(alt);
|
|
1580
1607
|
return;
|
|
1581
1608
|
}
|
|
1582
|
-
for (const child of node.toArray()) if (child
|
|
1609
|
+
for (const child of node.toArray()) if (isXText(child) || isXElem(child)) visit(child);
|
|
1583
1610
|
if (node.nodeName !== "paragraph" && node.length === 0) return;
|
|
1584
1611
|
out.push("\n");
|
|
1585
1612
|
};
|
|
1586
|
-
for (const child of fragment.toArray()) if (child
|
|
1613
|
+
for (const child of fragment.toArray()) if (isXText(child) || isXElem(child)) visit(child);
|
|
1587
1614
|
return out.join("").replace(/\n+$/, "").replace(/\n{3,}/g, "\n\n");
|
|
1588
1615
|
}
|
|
1589
1616
|
function yjsToHtml(fragment, label) {
|
|
1617
|
+
fragment = localizeFragment(fragment);
|
|
1590
1618
|
const title = escapeHtml(label);
|
|
1591
1619
|
const bodyParts = [];
|
|
1592
|
-
for (const child of fragment.toArray()) if (child
|
|
1620
|
+
for (const child of fragment.toArray()) if (isXElem(child)) {
|
|
1593
1621
|
const html = serializeBlockToHtml(child);
|
|
1594
1622
|
if (html) bodyParts.push(html);
|
|
1595
1623
|
}
|
|
@@ -3075,7 +3103,7 @@ function buildReverseLookup(manifest) {
|
|
|
3075
3103
|
* e.g. "My Project!" -> "my-project"
|
|
3076
3104
|
*/
|
|
3077
3105
|
function labelToFilename(label) {
|
|
3078
|
-
return label.toLowerCase().replace(/[^a-z0-9\s-]/g, "").replace(/\s+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "") || "untitled";
|
|
3106
|
+
return String(label ?? "").toLowerCase().replace(/[^a-z0-9\s-]/g, "").replace(/\s+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "") || "untitled";
|
|
3079
3107
|
}
|
|
3080
3108
|
/**
|
|
3081
3109
|
* Convert a filename back to a label (best-effort).
|
|
@@ -3174,7 +3202,8 @@ function simpleHash(str) {
|
|
|
3174
3202
|
function getTreeData(treeMap) {
|
|
3175
3203
|
const data = {};
|
|
3176
3204
|
treeMap.forEach((val, key) => {
|
|
3177
|
-
|
|
3205
|
+
const plain = val instanceof yjs.Map || !!val && typeof val === "object" && typeof val.toJSON === "function" && typeof val.get === "function" ? val.toJSON() : val;
|
|
3206
|
+
if (plain && typeof plain === "object") data[key] = plain;
|
|
3178
3207
|
});
|
|
3179
3208
|
return data;
|
|
3180
3209
|
}
|